Skip to content

raop: fix read-side deadlock in PatchedIceCastClient (#2849)#2850

Open
dantidote wants to merge 1 commit into
postlund:masterfrom
dantidote:fix-2849-read-deadlock
Open

raop: fix read-side deadlock in PatchedIceCastClient (#2849)#2850
dantidote wants to merge 1 commit into
postlund:masterfrom
dantidote:fix-2849-read-deadlock

Conversation

@dantidote

@dantidote dantidote commented May 15, 2026

Copy link
Copy Markdown

fixes #2849

When miniaudio.stream_any's decoder init issues a read larger than (BUFFER_SIZE - position) while position is still inside the seekable headroom, PatchedIceCastClient.read deadlocks against its downloader thread:

  • read() polls until len(buffer) >= num_bytes, where len(buffer) is size (post-position bytes).
  • The downloader's _buffer.fits(BLOCK_SIZE) guard checks the raw buffer length, not size, so once raw is at BUFFER_SIZE it refuses to add even though size has room to grow with consumption.
  • Reader waits for the buffer to grow; downloader cannot grow it until the reader consumes. Polling loop hits DEFAULT_TIMEOUT (10s) and miniaudio surfaces the resulting OperationTimeoutError as DecodeError('failed to init decoder', -1).

Small streams escape because the HTTP body completes within the 10s window: _stop_stream flips True and the loop's other exit fires.

Detect 'raw buffer cannot grow' in the polling loop and let self._buffer.get() return a short read. miniaudio's drmp3 handles short reads by re-calling; once subsequent reads advance position past HEADROOM_SIZE the buffer releases its headroom and becomes a normal sliding window, after which the downloader can refill.

Tests use pytest-httpserver to serve the existing static_3sec.ogg fixture (small, exercises the fast path) and a new audio_long.mp3 (~80 KiB silence, just over BUFFER_SIZE) to trigger the deadlock. On master the long test fails after ~10s with DecodeError(-1); with this fix both tests pass in under a second.

Also bump the miniaudio dev pin from 1.61 to 1.71 in requirements/requirements.txt. The deadlock fires on 1.71's decoder init call sequence (the new seek(0, END) plus extra round-trip leaves position at 2048 when the read(65536) fires); 1.61's init ends with seek(0, START) which resets position before the same read, hiding the bug. Real users pip install pyatv and resolve miniaudio>=1.45 to the newest available (1.71 today, and only 1.71 has Python 3.14 wheels), but the old dev pin meant pyatv's own CI didn't exercise the bug pattern users hit. Bumping aligns CI with what users actually run so the new regression test serves as a real guard.

When miniaudio.stream_any's decoder init issues a read larger than
(BUFFER_SIZE - position) while position is still inside the seekable
headroom, PatchedIceCastClient.read deadlocks against its downloader
thread:

- read() polls until len(buffer) >= num_bytes, where len(buffer) is
  size (post-position bytes).
- The downloader's _buffer.fits(BLOCK_SIZE) guard checks the *raw*
  buffer length, not size, so once raw is at BUFFER_SIZE it refuses
  to add even though size has room to grow with consumption.
- Reader waits for the buffer to grow; downloader cannot grow it
  until the reader consumes. Polling loop hits DEFAULT_TIMEOUT (10s)
  and miniaudio surfaces the resulting OperationTimeoutError as
  DecodeError('failed to init decoder', -1).

Small streams escape because the HTTP body completes within the 10s
window: _stop_stream flips True and the loop's other exit fires.

Detect 'raw buffer cannot grow' in the polling loop and let
self._buffer.get() return a short read. miniaudio's drmp3 handles
short reads by re-calling; once subsequent reads advance position
past HEADROOM_SIZE the buffer releases its headroom and becomes a
normal sliding window, after which the downloader can refill.

Tests use pytest-httpserver to serve the existing static_3sec.ogg
fixture (small, exercises the fast path) and a new audio_long.mp3
(~80 KiB silence, just over BUFFER_SIZE) to trigger the deadlock.
On master the long test fails after ~10s with DecodeError(-1); with
this fix both tests pass in under a second.

Also bump the miniaudio dev pin from 1.61 to 1.71 in
requirements/requirements.txt. The deadlock fires on 1.71's decoder
init call sequence (the new seek(0, END) plus extra round-trip
leaves position at 2048 when the read(65536) fires); 1.61's init
ends with seek(0, START) which resets position before the same
read, hiding the bug. Real users `pip install pyatv` and resolve
miniaudio>=1.45 to the newest available (1.71 today, and only 1.71
has Python 3.14 wheels), but the old dev pin meant pyatv's own CI
didn't exercise the bug pattern users hit. Bumping aligns CI with
what users actually run so the new regression test serves as a real
guard.
@Quentame

Quentame commented Jun 2, 2026

Copy link
Copy Markdown

You can add "fixes #2849" in the description so it links the issue with the PR and will auto-close it when the PR got merged 😉

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

miniaudio.DecodeError: failed to init decoder when streaming long MP3 files via RAOP (HomePod) — regression from 0.16.x

2 participants